JavaScript 学习笔记:异步编程

Photo by JESHOOTS.com from Pexels

为什么需要异步编程?

在我们的程序中,类似下面这样的代码,是由计算机的处理器一次性执行完毕的。代码的运行速度也很大程度上取决于计算机处理器的速度。

1
2
3
4
5
6
7
8
9
10
11
const sumTo = function(n) {
let sum = 0

for (let i = 0; i <= n; i++) {
sum += i
}

return sum
}

sumTo(10000)

但是,还有很多其他程序需要在处理器外面与其他设备交互。比如,有些程序可能需要通过计算机网络进行通信,或者从硬盘读取数据,而这些都比从内存读取代码慢得多了。

当这样的情形发生时,让处理器处于空闲状态是非常浪费资源的,因为可能有不少其他工作需要处理器在这时来处理。某种程度上来说,这取决于你的操作系统,它会切换处理器运行多个不同的程序。但是,当我们在运行单个程序,遇到等待网络请求的情形时,我们没法让处理器去做其他事情。

在同步编程的模型里,事情一件接着一件地执行。当调用一个性能开销很大的函数时,只有当函数内的操作都完成了才会返回。在这个函数运行期间,程序只能原地待命。

异步编程的模型允许多个事情在同一时间发生。当我们开始一项任务(比如从硬盘读取数据),我们的程序可以继续运行。当这个任务完成时,程序会通知我们并返回一个结果。

我们可以用一个小例子来对比同步编程和异步编程:一个程序从网络获取两个资源文件然后组合成需要的结果。

在一个同步的环境里,只有当网络请求全部完成时,请求函数才会返回。执行上面那个例子的最简单方式就是:一个接一个地发出网络请求。这样做的缺点是:第二个网络请求只有在第一个网络请求全部完成时才会开始。那么,完成上述例子的时间将是至少两项网络请求所需时间之和。

1
2
3
4
5
// 先请求第一个资源文件
request(resourceOne)

// 当上面的网络请求完成,才会继续执行第二个网络请求
request(resourceTwo)

针对这类问题的解决方式,在一个同步的环境里就是增加额外的线程。线程是指另一个运行中的程序,它的执行可能通过操作系统与另外的程序交织在一起。鉴于大多数现代计算机都包含多个核心的处理器,多个线程可能同时由不同的处理器核心来运行。对于上面的小例子来说,增加的第二个线程可以用来发起第二个网络请求,接着让两个线程都等待返回的结构,然后两个线程实现同步,再组合成需要的结果。

两个重量级的 JavaScript 平台,浏览器和 NodeJS 都需要进行一些费时的异步操作,而不是依赖于多线程。鉴于多线程编程是众所周知的难,所以这通常被认为是个好事情。

回调 Callback

异步编程的其中一种方式是:当函数执行耗时较长的操作时,增加一个额外的参数,即回调函数。当这个耗时的操作开始,等待它结束并返回一个结果时,回调函数以这个结果为参数被调用。

举个例子,在 NodeJS 和浏览器环境中都已实现的 setTimeout 函数,等待一段时间(以毫秒为单位)后再调用回调函数。

1
setTimeout(() => console.log('Tick'), 500)

回调实例:加载脚本

一个典型的用例:动态加载一个脚本,并在脚本加载完成后使用该脚本。在浏览器的 Web API 中,像 WindowXMLHttpRequest<script><img> 等元素都部署了 onload 事件处理接口,当资源加载完成后被触发。这里以加载 <script> 元素的资源为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const loadScript = function(src, callback) {
const script = document.createElement('script')
// 执行这一行代码,浏览器会发起网络请求获取脚本资源
script.src = src

// 资源加载成功时执行
script.onload = () => callback(null, script)
// 资源加载失败时执行
script.onerror = () => callback(new Error(`Script load error for ${src}`))

document.head.appendChild(script)
}

// 使用
loadScript('/my/script.js', (error, script) => {
// “错误优先”风格的回调, 在 NodeJS 生态中被广泛使用
if (error) {
// 处理错误
} else {
// 脚本加载成功
}
})

回调地狱

有的时候,我们需要按照一定的顺序来执行一连串的异步任务。如果使用回调函数的方式来处理,就会形成嵌套的异步任务。假设我们现在要依次获取 3 个脚本资源,那么就会形成下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
loadScript('one.js', (error, script) => {
if (error) {
// 处理错误
} else {
one()
// 继续获取第 2 个脚本
loadScript('two.js', (error, script) => {
if (error) {
// 处理错误
} else {
two()
// 继续获取第 3 个脚本
loadScript('three.js', (error, script) => {
if (error) {
// 处理错误
} else {
three()
// 获取到所有脚本资源
// ...
}
})
}
})
}
})

上面的代码即构成了回调金字塔,或者称回调地狱。这样的代码不仅不易阅读,后续维护成本也高。

Promise

为了解决回调地狱,Promise 诞生了。一个 Promise 的实例,是一个代表着最终会完成或者失败的异步操作的对象。新建一个 Promise 实例的语法如下:

1
2
3
const promise = new Promise((resolve, reject) => {
// executor
})

上面这种新建 Promise 实例的方式,通常只会在封装基于回调处理异步操作的陈旧代码时用到(绝大部分时候,我们是 Promise 实例的使用者)。后面会给出详细例子。

executor

传递给 new Promise 的参数是一个叫做 executor 的函数,它会在 promise 被创建的时候自动立即执行。executor 函数接受 2 个参数,resolve 函数 和 reject 函数,它们是由引擎预定义的,我们不必创建它们。我们应当在执行成功时调用 resolve 函数,执行失败时调用 reject 函数。

创建的 Promise 实例具备 2 个内部属性:

  • state 初始值为 pending,之后会转变为 fulfilled 或者 rejected。
  • result 初始值为 undefined,之后会转变为结果或者错误对象

不可逆过程

每个 Promise 实例可能的状态有 3 种:初始状态 pending,完成状态 fulfilled 和 rejected。即一个 Promise 实例要么由 pending 变成 fulfilled,要么由 pending 变成 rejected,并且此过程不可逆,一旦到了完成状态就无法改变。如下图所示:

resolve 实例

1
2
3
4
const promise = new Promise((resolve, reject) => {
// 模拟一项任务,1 秒后完成,成功并返回结果 "done"
setTimeout(() => resolve('done'), 1000)
})

reject 实例

1
2
3
4
const promise = new Promise((resolve, reject) => {
// 模拟一项任务,1 秒后完成,失败并返回错误对象
setTimeout(() => reject(new Error('error occurs')), 1000)
})

resolve 与 reject 注意事项

resolve 函数或者 reject 函数其中任意一个只会被调用一次,后续的调用都将会忽略:

1
2
3
4
5
6
const promise = new Promise((resolve, reject) => {
resolve('done')

reject(new Error('…')) // 忽略,不会执行
setTimeout(() => resolve('…')) // 忽略,不会执行
})

resolve 函数或者 reject 函数都只接受一个参数,多余的参数将会忽略。

then

本质上,Promise 做的事情是,将一项耗费时间的任务(我们传入的 executor 函数)自动立即执行,然后在任务完成后将这项任务的结果保存到内部属性 result。这个结果可能是我们传入 resolve 函数的任意值,也可能是我们传入 reject 函数的错误对象。并且这个结果一旦形成就不会再改变。

我们可以通过 Promise 实例的 then 方法来获取结果。它可能被成功解决,返回一个结果:

1
2
3
4
5
6
7
8
const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve('done!'), 1000)
})

promise.then(
result => alert(result), // shows "done!" after 1 second
error => alert(error) // doesn't run
)

也可能失败,返回错误对象:

1
2
3
4
5
6
7
8
9
const promise = new Promise(function(resolve, reject) {
setTimeout(() => reject(new Error('Whoops!')), 1000)
})

// reject runs the second function in .then
promise.then(
result => alert(result), // doesn't run
error => alert(error) // shows "Error: Whoops!" after 1 second
)

链式调用

Promise 的 then 方法支持链式调用。注意下面代码中 then 方法内的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
new Promise(resolve => {
resolve(1);
}).then(result => {
console.log(result); // 1
return result * 2; // 返回一个基本数据类型的值
}).then(result => {
console.log(result); // 2
return new Promise(resolve => resolve(100)); // 返回一个 Promise 的实例
}).then(result => {
console.log(result); // 100
return { a: 1 }; // 返回一个对象
}).then(result => console.log(result); // { a: 1 }

thenable

事实上,then 方法只要返回一个部署了 then 方法的任意对象,引擎就会将它当做 Promise 的实例来看待。第三方库可以利用这一点来兼容原生的 Promise。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Thenable {
constructor(num) {
this.num = num
}
then(resolve, reject) {
alert(resolve) // function() { native code }
// resolve with this.num*2 after the 1 second
setTimeout(() => resolve(this.num * 2), 1000) // (**)
}
}

new Promise(resolve => resolve(1))
.then(result => {
return new Thenable(result) // (*)
})
.then(alert) // shows 2 after 1000ms

catch

本质上是基于 then 实现的,只不过专注于错误处理。由于链式调用 then 方法可以传递结果和错误,所以最佳实践通常是这样:

1
2
3
4
5
6
7
8
9
10
11
12
new Promise(resolve => {
setTimeout(() => resolve('done'), 1000)
})
.then(result => {
// ....
})
.then(result => {
// ...
})
.catch(error => {
// 最后统一处理错误
})

finally

try...catch...finally 类似,主要用于做一些清理扫尾的工作,略。

Promise 实例:加载脚本

使用 Promise 改写上面使用回调实现的加载脚本实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const loadScript = function(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = src

script.onload = () => resolve(script)
script.onerror = () =>
reject(new Error(`error when load script for ${src}`))

document.head.appendChild(script)
})
}

// 使用
const promise = loadScript(
'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js'
)

promise.then(
script => alert(`${script.src} is loaded!`),
error => alert(`Error: ${error.message}`)
)

promise.then(script => alert('One more handler to do something else!'))

Promise 串行加载资源

链式调用 Promise 实例的 then 方法可以规避上面提到的回调地狱。使用 Promise 改写上面的按照顺序获取脚本资源的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 串行获取资源
loadScript('one.js')
.then(script => {
one()
return loadScript('two.js')
})
.then(script => {
two()
return loadScript('three.js')
})
.then(script => {
three()
// 获取到了全部脚本
// ...
})

串行执行异步操作可以改写成更聪明简洁的写法:

1
2
3
4
5
6
7
8
const asyncFn1 = x => x + 1
const asyncFn2 = x => x + 10
const asyncFn3 = x => x + 100

const asyncs = [asyncFn1, asyncFn2, asyncFn3]
asyncs
.reduce((p, f) => p.then(f), Promise.resolve(1))
.then(result3 => console.log('result3:', result3)) // result3: 112

上面的代码等同于:

1
Promise.resolve(1).then(asyncFn1).then(asyncFn2).then(asyncFn3)。

还可以进一步抽象出一个组合函数,这个组合函数以一组函数为参数,按照参数的顺序依次执行,上一个函数的输出结果作为下一个函数的输入参数。这通常被使用在函数式编程中:

1
2
3
4
5
6
7
8
const applyAsync = (acc, val) => acc.then(val)
// 尝试从该函数的调用者的角度去思考,更易读懂下面这行代码
const composeAsync = (...funcs) => x =>
funcs.reduce(applyAsync, Promise.resolve(x))

// 使用
const transformData = composeAsync(fn1, fn2, fn3)
const result3 = transformData(data)

Promise 实例:fetch

Promise 通常被用于网络请求。这里我们以 fetch 方法为例,从远程服务器获取用户数据 user.jsonfetch 方法的基本用法如下:

1
const promise = fetch(url) // 返回一个 Promise 的实例

详细的例子如下:

1
2
3
4
5
6
7
8
9
10
fetch('https://javascript.info/article/promise-chaining/user.json')
// 下面的 then 方法在服务器响应时执行
.then(response => {
// 当我们接收到服务器发送的全部响应信息,response.text() 返回一个包含全部响应结果的新的 Promise 实例
return response.text()
})
.then(text => {
// text 即是服务器发送过来的响应内容
alert(text) // {"name": "iliakan", isAdmin: true}
})

response 还有一个 response.json() 方法,它会读取服务器的响应内容并将之解析为 JSON。很多时候使用它来代替 response.text() 会更加方便:

1
2
3
fetch('https://javascript.info/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => alert(user.name)) // iliakan

接着让我们进一步扩展上面的例子,比如使用获取的用户数据做点什么。这里我们用获取到的用户名,查询该用户的 GitHub 信息,然后在网页上显示用户的头像:

1
2
3
4
5
6
7
8
9
10
11
12
fetch('https://javascript.info/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => fetch(`https://api.github.com/user/${user.name}`))
.then(response => response.json())
// 用户的头像地址在 githubUser.avatar_url
.then(githubUser => {
const img = document.createElement('img')
img.src = githubUser.avatar_url
document.body.append(img)

setTimeout(() => img.remove(), 3000) // (*)
})

注意到上面代码中 (*) 这一行,用户头像显示 3 秒后将被移除。为了让代码更具有扩展性,即可以继续往下链式调用,同时传递数据,我们可以让第 4 个 then 方法返回一个新的 Promise 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fetch('https://javascript.info/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`))
.then(response => response.json())
.then(
githubUser =>
new Promise((resolve, reject) => {
// (*)
const img = document.createElement('img')
img.src = githubUser.avatar_url
document.body.append(img)

setTimeout(() => {
// 先移除头像
img.remove()
// 接着往下链式传递之前的数据
resolve(githubUser) // (*)
}, 3000)
})
)

通常来说,最好让每一个异步操作都返回 Promise 的实例,这样的代码更具扩展性,即使我们现在不需要继续往下链式调用,可能将来会用到。
最后,让我们优化上面的代码,将部分代码抽象成可复用的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const loadJson = function(url) {
return fetch(url).then(response => response.json());
};

const loadGithubUser(name) {
return fetch(`https://api.github.com/users${name}`).then(response => response.json());
};

const showAvatar(githubUser) {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.src = githubUser.avatar_url;

img.onload = () => {
// 图片加载完成 3 秒后移除图片,并继续往下链式传递该 GitHub 用户数据
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
};

document.body.append(img);
})
};

// 使用
loadJson("https://javascript.info/article/promise-chaining/user.json")
.then(user => loadGithubUser(user.name))
.then(githubUser => showAvatar(githubUser)) // 可简写为 .then(showAvatar)
.then(githubUser => new Promise(resolve => {
console.log(`Finished showing ${githubUser.name}`);
resolve(githubUser);
}));

Promise 错误处理

Promise 实例的错误可以沿着 then 方法往下链式传递,所以最佳实践通常是在最后统一处理错误,以上面的例子为例可以这样做:

1
2
3
4
5
6
7
8
9
10
11
loadJson('https://javascript.info/article/promise-chaining/user.json')
.then(user => loadGithubUser(user.name))
.then(githubUser => showAvatar(githubUser)) // 可简写为 .then(showAvatar)
.then(
githubUser =>
new Promise(resolve => {
console.log(`Finished showing ${githubUser.name}`)
resolve(githubUser)
})
)
.catch(error => console.log(error.message))

隐式的 try...catch

Promise 实例的 executor 函数和 then 函数内都存在一个隐式的 try...catch。即如果出现错误,后续 .catch 会自动捕获所有错误:

1
2
3
new Promise((resolve, reject) => {
throw new Error('Whoops!')
}).catch(alert) // Error: Whoops!

实际上相当于以下代码:

1
2
3
new Promise((resolve, reject) => {
reject(new Error('Whoops!'))
}).catch(alert) // Error: Whoops!

then 方法内抛出错误,也是一样,会被传递到最近的 .catch

1
2
3
4
5
6
7
new Promise((resolve, reject) => {
resolve('ok')
})
.then(result => {
throw new Error('Whoops!') // rejects the promise
})
.catch(alert) // Error: Whoops!

不仅是抛出的错误,对于所有错误也是如此:

1
2
3
4
5
6
7
new Promise((resolve, reject) => {
resolve('ok')
})
.then(result => {
blabla() // no such function
})
.catch(alert) // ReferenceError: blabla is not defined

实例

以上面的 fetch 方法请求用户数据为例,类似下面的错误处理方式,效果依然很不理想:

1
2
3
4
5
6
fetch('no-such-user.json') // (*)
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`)) // (**)
.then(response => response.json())
.catch(alert) // SyntaxError: Unexpected token < in JSON at position 0
// ...

最后的 .catch 方法捕获的错误非常宽泛,无法直观的看出究竟是哪里出了错误,以及进一步的错误信息。对于上面的代码而言,可能有如下错误:

  • 请求 no-such-user.json数据,服务器返回的响应是一个 404 或者 500 的错误提示页面。
  • 成功拿到用户数据,但是请求 GitHub 用户信息接口的时候,返回的响应是一个 404 或者 500 的错误提示页面。

我们可以增加一个步骤,检查 response.status 代表的 HTTP 状态码是否为 200。如果不是则抛出一个定制的 HttpError 错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class HttpError extends Error {
constructor(response) {
super(`${response.status} for ${response.url}`)
this.name = 'HttpError'
this.response = response
}
}

fetch('.../no-such-user.json')
.then(response => {
if (response.status === 200) {
return response.json()
} else {
throw new HttpError(response)
}
})
.catch(err => console.log(err)) // 404 for .../no-such-user.json

再看一个针对特定错误进行处理的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const demoGithubUser = function() {
let name = prompt('Enter a name?', 'iliakan')

return loadJson(`https://api.github.com/users/${name}`)
.then(user => {
alert(`Full name: ${user.name}.`)
return user
})
.catch(err => {
if (err instanceof HttpError && err.response.status == 404) {
// (*)
alert('No such user, please reenter.')
return demoGithubUser()
} else {
throw err // (*)
}
})
}

demoGithubUser()

上面的代码,注意 (*) 这一行,当没有查询到对应名字的 GitHub 用户时,弹出没有此用户的信息并让用户重新输入有效的 GitHub 用户名。而对于其他错误,重新抛出。

unhandled rejections

对于 Promise 内未处理的错误,比如 catch 重新抛出的错误,或者根本没有 catch 方法,大多数引擎都会追踪到这些未处理错误,并创建一个全局的错误。对于浏览器环境而言,我们可以通过监听全局事件 unhandledrejection 来访问错误并作出处理。NodeJS 环境也有类似的机制离开处理未处理的 Promise 错误。

1
2
3
4
5
6
7
8
window.addEventListener('unhandledrejection', event => {
alert(event.promise) // [object Promise] - the promise that generated the error
alert(event.reason) // Error: Whoops! - the unhandled error object
})

new Promise(resolve => {
throw new Error('whoops')
})

Promise 静态方法

Promise 一共有以下 4 个静态方法:

  • Promise.resolve
  • Promise.reject
  • Promise.all
  • Promise.race

Promise.resolve

主要使用场景:将一个值封装为 Promise 的实例。举例,下面的代码实现的功能是:假如某个 url 的资源之前已经获取过,可以通过 then 方法直接返回资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const loadCached = function (url) {
const cache = loadCached.cache || (loadCached.cache = new Map());

if (cache.has(url)) {
// 封装缓存结果,保证返回的是 Promise 实例
return Promise.resolve(cache.get(url));
}

return fetch(url)
.then(response => response.text())
.then(result => {
cache.set(url, result);
return result;
});
};

// 使用
loadCached("https://example.com/user.json")
.then(result => /* do something with result */)

Promise.reject

用于创建包含错误对象的 Promise 实例,很少用到。

Promise.all

并发执行异步操作,会等待所有异步任务完成,返回的结果是由各项异步任务返回结果组成的数组;如果其中任意一个异步任务出错,直接返回错误作为最终结果,其他异步任务的结果将被忽略。
基本用法:

1
2
3
Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
/* use result1, result2 and result3 */
})

考虑如下代码:

1
2
3
4
5
6
Promise.all([
new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
new Promise(resolve => setTimeout(() => resolve(3), 1000)), // 3
4 // 被自动封装为 Promise.resolve(4)
]).then(alert) // 3 秒后,返回的结果是 [1, 2, 3, 4]

then 方法返回的结果是一个数组,数组成员为传入 Promise.all 的 Promise 实例 resolve 的结果,顺序也与 Promise.all 的参数顺序一致,而与异步操作的时间无关。

一个常用的技巧是,将一组任务的数据映射为一个成员为 Promise 实例的数组,然后使用 Promise.all 来并发执行异步任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let urls = [
'https://api.github.com/users/iliakan',
'https://api.github.com/users/remy',
'https://api.github.com/users/jeresig'
]

// 映射为 Promise 实例
const requests = urls.map(url => fetch(url))

// 等待所有异步操作完成
// 如果其中有一个异步操作失败,将立即返回对应的错误对象
Promise.all(requests).then(responses => {
responses.forEach(response => alert(`${response.url}:${response.status}`))
return responses
})
实现容错的并发异步任务

Promise.all 方法本身不具备容错性,即一旦有一个异步任务错误便立即返回错误信息。但是,有时候我们希望的结果是这样的:返回一个数组,包含处理成功的异步任务结果和处理失败的异步任务的错误信息。为了实现这一点,需要使用 catch 捕获错误,让错误不被抛出,同时让错误继续往下传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
const urls = [
'https://api.github.com/users/iliakan',
'https://api.github.com/users/remy',
'http://no-such-url'
]

Promise.all(urls.map(url => fetch(url).catch(error => error))).then(
responses => {
// 返回结果的数组包含以下 3 项
// [object Response], [object Response], TypeError: Failed to fetch
responses.forEach(response => alert(response))
}
)

Promise.race

Promise.all 类似,不同点在于,Promise.race 不会等待所有异步任务完成,而是只要其中一个完成或者出错,就立即返回处理结果或者错误对象,忽略掉后续的结果或者错误。这点与它的名字 race (赛跑)相契合。

1
2
3
4
5
6
7
Promise.race([
new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
new Promise((resolve, reject) =>
setTimeout(() => reject(new Error('Whoops!')), 2000)
),
new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert) // 1

Promisify

一些第三方库的遗留代码仍然使用基于回调的方式处理异步,而 Promise 更加方便好用,所以需要一种方式,将接受回调函数作为参数的函数转化为返回值为 Promise 实例的函数。
一个典型的例子是 setTimeout() 函数:

1
setTimeout(() => alert('At least 3 seconds passed'), 3000)

在之前的一篇有关错误处理的博文中讲到,try...catch 结构无法捕获 setTimeout() 函数内的错误,而这一点正是基于回调的 setTimeout() 函数备受指责的原因。

1
2
3
4
5
6
7
8
try {
setTimeout(() => {
throw new Error('error in setTimeout') // 脚本在这行直接挂掉了
}, 0)
} catch (error) {
// 无法捕获错误,因为抛出错误时已经离开了 try...catch 结构体
alert('this line will not be printed')
}

让我们使用 Promise 封装它:

1
2
3
4
5
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
// 使用
wait(3000)
.then(() => alert('At least 3 seconds passed'))
.catch(failureCallback)

一个更加具体的加载脚本的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 使用 Promise 封装基于回调处理异步的代码

// 回调
const loadScript = function(src, callback) {
const script = document.createElement('script')
script.src = src

script.onload = () => callback(null, script)
script.onerror = () => callback(new Error(`Load script error for ${src}`))

document.head.append(script)
}

// 使用
loadScript('path/script.js', (error, result) => {
if (error) alert(error)
else alert(result)
})

// promisify
const loadScriptPromise = function(src) {
return new Promise((resolve, reject) => {
loadScript(src, (error, result) => {
if (error) reject(error)
else resolve(result)
})
})
}

// 使用
loadScriptPromise('path/script.js').then(res => alert(res))

鉴于这是一个常见的需求,可以将其抽象成一个单独的函数 promisify

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const promisify = function(f) {
const wrapper = function(...args) {
return new Promise((resolve, reject) => {
// 定制 f 的回调函数
const callback = function(error, result) {
if (error) {
reject(error)
} else {
resolve(result)
}
}
// 将定制的回调函数添加到 f 参数的末尾
args.push(callback)
f.apply(this, args)
})
}
return wrapper
}

// 使用
const loadScriptPromise = promisify(loadScript)
loadScriptPromise('https://api.github.com/users/cddbysj')
.then(res => alert(res))
.catch(err => alert(err))

上面的代码存在一个不足,只能接受形式为 (err, result) => {...} 的回调函数,即回调只能接受两个参数,一个代表错误,另一个代表结果。如果遇到类似(err, result1, result2, ...) => {...} 这样的回调函数则无法正常运行。针对这一问题的改进如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 回调
const loadScript = function(src, callback) {
const script = document.createElement('script')
script.src = src

script.onload = () => callback(null, script, 'test1', 'test2')
script.onerror = () => callback(new Error(`Load script error for ${src}`))

document.head.append(script)
}

// promisify
const promisify = function(f) {
const wrapper = function(...args) {
return new Promise((resolve, reject) => {
const callback = function(error, ...result) {
if (error) {
reject(error)
} else {
resolve(result.length === 1 ? result[0] : result) // (*)
}
}
args.push(callback)
f.apply(this, args)
})
}
return wrapper
}

// 使用
const loadScriptPromise = promisify(loadScript)
loadScriptPromise('https://api.github.com/users/cddbysj')
.then(res => alert(res)) // [script, "test1", "test2"]
.catch(err => alert(err))

参考链接及外部资料

JavaScript.info - promise-basics

Using promise - MDN

Promise - MDN